diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/websites/[websiteId]/realtime | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/realtime')
7 files changed, 414 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx new file mode 100644 index 0000000..6e2495b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx @@ -0,0 +1,31 @@ +import { IconLabel } from '@umami/react-zen'; +import { useCallback } from 'react'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; + +export function RealtimeCountries({ data }) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + + const renderCountryName = useCallback( + ({ label: code }) => ( + <IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} /> + ), + [countryNames, locale], + ); + + return ( + <ListTable + title={formatMessage(labels.countries)} + metric={formatMessage(labels.visitors)} + data={data.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + renderLabel={renderCountryName} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx new file mode 100644 index 0000000..2b9d881 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx @@ -0,0 +1,17 @@ +import { useMessages } from '@/components/hooks'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; + +export function RealtimeHeader({ data }: { data: any }) { + const { formatMessage, labels } = useMessages(); + const { totals }: any = data || {}; + + return ( + <MetricsBar> + <MetricCard label={formatMessage(labels.views)} value={totals.views} /> + <MetricCard label={formatMessage(labels.visitors)} value={totals.visitors} /> + <MetricCard label={formatMessage(labels.events)} value={totals.events} /> + <MetricCard label={formatMessage(labels.countries)} value={totals.countries} /> + </MetricsBar> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx new file mode 100644 index 0000000..1076361 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -0,0 +1,206 @@ +import { Column, Heading, IconLabel, Row, SearchField, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { useFormat } from '@/components//hooks/useFormat'; +import { Avatar } from '@/components/common/Avatar'; +import { Empty } from '@/components/common/Empty'; +import { + useCountryNames, + useLocale, + useMessages, + useMobile, + useNavigation, + useTimezone, + useWebsite, +} from '@/components/hooks'; +import { Eye, User } from '@/components/icons'; +import { FilterButtons } from '@/components/input/FilterButtons'; +import { Lightning } from '@/components/svg'; +import { BROWSERS, OS_NAMES } from '@/lib/constants'; + +const TYPE_ALL = 'all'; +const TYPE_PAGEVIEW = 'pageview'; +const TYPE_SESSION = 'session'; +const TYPE_EVENT = 'event'; + +const icons = { + [TYPE_PAGEVIEW]: <Eye />, + [TYPE_SESSION]: <User />, + [TYPE_EVENT]: <Lightning />, +}; + +export function RealtimeLog({ data }: { data: any }) { + const website = useWebsite(); + const [search, setSearch] = useState(''); + const { formatMessage, labels, messages, FormattedMessage } = useMessages(); + const { formatValue } = useFormat(); + const { locale } = useLocale(); + const { formatTimezoneDate } = useTimezone(); + const { countryNames } = useCountryNames(locale); + const [filter, setFilter] = useState(TYPE_ALL); + const { updateParams } = useNavigation(); + const { isPhone } = useMobile(); + + const buttons = [ + { + label: formatMessage(labels.all), + id: TYPE_ALL, + }, + { + label: formatMessage(labels.views), + id: TYPE_PAGEVIEW, + }, + { + label: formatMessage(labels.visitors), + id: TYPE_SESSION, + }, + { + label: formatMessage(labels.events), + id: TYPE_EVENT, + }, + ]; + + const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp'); + + const getIcon = ({ __type }) => icons[__type]; + + const getDetail = (log: { + __type: string; + eventName: string; + urlPath: string; + browser: string; + os: string; + country: string; + device: string; + }) => { + const { __type, eventName, urlPath, browser, os, country, device } = log; + + if (__type === TYPE_EVENT) { + return ( + <FormattedMessage + {...messages.eventLog} + values={{ + event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>, + url: ( + <a + key="a" + href={`//${website?.domain}${urlPath}`} + target="_blank" + rel="noreferrer noopener" + > + {urlPath} + </a> + ), + }} + /> + ); + } + + if (__type === TYPE_PAGEVIEW) { + return ( + <a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> + {urlPath} + </a> + ); + } + + if (__type === TYPE_SESSION) { + return ( + <FormattedMessage + {...messages.visitorLog} + values={{ + country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>, + browser: <b key="browser">{BROWSERS[browser]}</b>, + os: <b key="os">{OS_NAMES[os] || os}</b>, + device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>, + }} + /> + ); + } + }; + + const TableRow = ({ index, style }) => { + const row = logs[index]; + return ( + <Row alignItems="center" style={style} gap> + <Row minWidth="30px"> + <Link href={updateParams({ session: row.sessionId })}> + <Avatar seed={row.sessionId} size={32} /> + </Link> + </Row> + <Row minWidth="100px"> + <Text wrap="nowrap">{getTime(row)}</Text> + </Row> + <IconLabel icon={getIcon(row)}> + <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate> + {getDetail(row)} + </Text> + </IconLabel> + </Row> + ); + }; + + const logs = useMemo(() => { + if (!data) { + return []; + } + + let logs = data.events; + + if (search) { + logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => { + return [ + eventName, + urlPath, + os, + formatValue(browser, 'browser'), + formatValue(country, 'country'), + formatValue(device, 'device'), + ] + .filter(n => n) + .map(n => n.toLowerCase()) + .join('') + .includes(search.toLowerCase()); + }); + } + + if (filter !== TYPE_ALL) { + return logs.filter(({ __type }) => __type === filter); + } + + return logs; + }, [data, filter, formatValue, search]); + + return ( + <Column gap> + <Heading size="2">{formatMessage(labels.activity)}</Heading> + {isPhone ? ( + <> + <Row> + <SearchField value={search} onSearch={setSearch} /> + </Row> + <Row> + <FilterButtons items={buttons} value={filter} onChange={setFilter} /> + </Row> + </> + ) : ( + <Row alignItems="center" justifyContent="space-between"> + <SearchField value={search} onSearch={setSearch} /> + <FilterButtons items={buttons} value={filter} onChange={setFilter} /> + </Row> + )} + + <Column> + {logs?.length === 0 && <Empty />} + {logs?.length > 0 && ( + <FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}> + {TableRow} + </FixedSizeList> + )} + </Column> + <SessionModal websiteId={website.id} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx new file mode 100644 index 0000000..6220c69 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx @@ -0,0 +1,58 @@ +'use client'; +import { Grid } from '@umami/react-zen'; +import { firstBy } from 'thenby'; +import { GridRow } from '@/components/common/GridRow'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; +import { useMobile, useRealtimeQuery } from '@/components/hooks'; +import { RealtimeChart } from '@/components/metrics/RealtimeChart'; +import { WorldMap } from '@/components/metrics/WorldMap'; +import { percentFilter } from '@/lib/filters'; +import { RealtimeCountries } from './RealtimeCountries'; +import { RealtimeHeader } from './RealtimeHeader'; +import { RealtimeLog } from './RealtimeLog'; +import { RealtimePaths } from './RealtimePaths'; +import { RealtimeReferrers } from './RealtimeReferrers'; + +export function RealtimePage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useRealtimeQuery(websiteId); + const { isMobile } = useMobile(); + + if (isLoading || error) { + return <PageBody isLoading={isLoading} error={error} />; + } + + const countries = percentFilter( + Object.keys(data.countries) + .map(key => ({ x: key, y: data.countries[key] })) + .sort(firstBy('y', -1)), + ); + + return ( + <Grid gap="3"> + <RealtimeHeader data={data} /> + <Panel> + <RealtimeChart data={data} unit="minute" /> + </Panel> + <Panel> + <RealtimeLog data={data} /> + </Panel> + <GridRow layout="two"> + <Panel> + <RealtimePaths data={data} /> + </Panel> + <Panel> + <RealtimeReferrers data={data} /> + </Panel> + </GridRow> + <GridRow layout="one-two"> + <Panel> + <RealtimeCountries data={countries} /> + </Panel> + <Panel gridColumn={isMobile ? null : 'span 2'} padding="0"> + <WorldMap data={countries} /> + </Panel> + </GridRow> + </Grid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx new file mode 100644 index 0000000..1f90ad8 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx @@ -0,0 +1,45 @@ +import thenby from 'thenby'; +import { useMessages, useWebsite } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { percentFilter } from '@/lib/filters'; + +export function RealtimePaths({ data }: { data: any }) { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { urls } = data || {}; + const limit = 15; + + const renderLink = ({ label: x }) => { + const domain = x.startsWith('/') ? website?.domain : ''; + return ( + <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener"> + {x} + </a> + ); + }; + + const pages = percentFilter( + Object.keys(urls) + .map(key => { + return { + x: key, + y: urls[key], + }; + }) + .sort(thenby.firstBy('y', -1)) + .slice(0, limit), + ); + + return ( + <ListTable + title={formatMessage(labels.pages)} + metric={formatMessage(labels.views)} + renderLabel={renderLink} + data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx new file mode 100644 index 0000000..9fd4477 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx @@ -0,0 +1,45 @@ +import thenby from 'thenby'; +import { useMessages, useWebsite } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { percentFilter } from '@/lib/filters'; + +export function RealtimeReferrers({ data }: { data: any }) { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { referrers } = data || {}; + const limit = 15; + + const renderLink = ({ label: x }) => { + const domain = x.startsWith('/') ? website?.domain : ''; + return ( + <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener"> + {x} + </a> + ); + }; + + const domains = percentFilter( + Object.keys(referrers) + .map(key => { + return { + x: key, + y: referrers[key], + }; + }) + .sort(thenby.firstBy('y', -1)) + .slice(0, limit), + ); + + return ( + <ListTable + title={formatMessage(labels.referrers)} + metric={formatMessage(labels.views)} + renderLabel={renderLink} + data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx new file mode 100644 index 0000000..1552196 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RealtimePage } from './RealtimePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RealtimePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Real-time', +}; |